隨著系統逐漸成長,資料量也逐漸上升,我們也會開始面臨資料呈現與管理的問題。過多的資料難以顯示且效能低落,因此我們開始思考如何在分批索取資料的同時,又能夠不影響使用體驗,而這就是 Pagination (分頁) 要做到的事情。而 Pagination 不只是單純的「要多少給多少」,還需要搭配額外的 Pagination information (分頁資訊) ,如分頁數、資料總數等等,那在 GraphQL 中該如何實作呢 ?
如果你會去翻翻一些知名公司釋出的 GraphQL API Explorer 如 GitHub 、 Shopify ,就可以發現在一些含有大量資料的概念在命名上都會在結尾加 Connection 以表示分頁,如 Github 要得到一個 User 的 Repository 就不是用 [Repository]
而是用 RepositoryConnection
來表示,如果點進去 RespositoryConnection
就會得到一張令人眼花撩亂的資料結構。
不過不用擔心,Connection 、 Edge 、 Cursor 這些用法不是 GitHub 發明的,而是遵照 Relay Connections Specification 去實作,所以跟著我底下的介紹,就能輕鬆上手 !
說到分頁其實一開始直覺上會想使用 offset/limit-based pagination 而不是上述提到的例子。 offset/limit-based pagination 用 offset 來設立資料取得的起始點 (就是第幾個開始),再用 limit 去取得實際所需的數量。假設今天我們有個 Schema 如下:
type Post {
id: ID!
title: String
body: String
"Unix timestamp milliseconds (毫秒) 格式"
createdAt: String
}
Query {
posts(offset: Int = 0, limit: Int = 100)
}
還有 Resolver functins (這裏為了讓大家都能懂,因此以簡單的 sql 形式來做 demo ,並非實際的能跑的程式)
const resolvers = {
Query: {
posts(root, { offset, limit }, { db }) {
return db.query('SELECT * FROM post LIMIT $1 OFFSET $2', [limit, offset]);
}
}
};
當我們每頁 posts 數量為 4 ,那第一頁的 query 就會如下:
query {
posts(offset: 0, limit: 4)
}
接著就會出現前 4 筆 posts 資料。如果要拿第二頁資訊的話, query 會如下:
query {
posts(offset: 4, limit: 4)
}
之後以此類推...是不是非常簡單呢!但是在簡單的背後也是要付出代價, offset/limit-based pagination 有以下缺點:
當 offset 越來越大時,每筆 db operation 會需要花更多的時間。
offset/limit-based pagination 無法處理在換頁時被刪除或是新增的資料。
假如如你在換下頁的前一秒有人刪除了第一筆資料,那就會導致你的下一頁資料「少了第一筆多了最後一筆」,也就是 offset
會自動加一。可見以下圖示,原本第二頁要 id 5, 6, 7, 8 結果拿到 6, 7, 8, 9。
因此我們需要 Cursored-based Pagination 來確保我們拿到的每頁資料都如預期不會亂移位!
在 Cursored-based Pagination , offset 變成 cursor 且 cursor 可以用來表示精確的起始點而非只是算數量,從 Offset/Limit-based Pagination 的「在 offset 數量後的資料取得 limit 數量的資料」變成 「從 cursor 這筆資料後取得 limit 數量的資料」
那接著就會有疑問,所以 cursor 要如何記錄資料位址呢? 使用 Cursored-based Pagination 時有一個非常重要的要求,那就是資料必須有明確且固定的排序機制,不然 cursor 就失去了紀錄位址的功能。
以 Post 來說,通常我們都會以創建順序來排序,新的在前舊的在後,所以就可以利用 createdAt
來做 cursor 紀錄點。當然也可以使用 id
,只是通常 id
會以 uuid 亂碼形式呈現,因此可以考慮與 createdAt
一起組合,但不太適合單獨撐場面。
那就讓我們看看新的 Schema:
Query {
# 這裡的 cursor 僅能輸入相對應得 date 格式 (Unix timestamp milliseconds) 才能做正確比較
post(cursor: String, limit: Int = 50): [Posts!]!
}
接著做 Resolver Function 時需注意,因為越新的(createdAt
越大)越前面,所以要挑出 cursor 後面的資料時, 都是選出比 cursor (這裡是 createdAt
) 還要小的資料,詳情見以下程式碼:
const { UserInputError } = require('apollo-server');
const resolvers = {
Query {
posts: (root, { cursor, limit }, { db }) => {
// 1. 如果有 cursor 就檢查 cursor 格式
if (cursor && isNaN(new Date(Number(cursor)).getTime())) {
throw new UserInputError('Incorrect Cursor Format')
}
// 2. 如果沒 curosr 就直接傳進 limit (第一頁); 有則加入 Where 判斷
return cursor
? db.query('SELECT * FROM post WHERE created_at < $1 ORDER BY created_at DESC LIMIT $2', [cursor, limit])
: db.query('SELECT * FROM post LIMIT $1 ORDER BY created_at DESC', [limit]);
}
}
}
當我們分頁的每頁數量為 4 ,那第一頁的 query 就會如下
query {
posts(limit: 4)
}
這時候取得最後一筆 (第 4 筆) 資料的 createdAt
(假設是 1500000000004) ,那第二頁 query 則會是
query {
posts(cursor: 1500000000004, limit: 4)
}
這樣即使在中途有人新增或是刪除資料,下一頁的資料都會是我們所期望的,如下圖所示:
這邊可能會有人開始質疑,這跟前面 GitHub 的範例不太一樣啊!別急,接下來讓我們為分頁添加一些資訊並調整一下資料結構,就會成為 GraphQL 的 Connection 模式 ,而這個模式可以讓我們更有組織地使用 Pagination 並且也支援前後跳頁。
首先在使用分頁時我們需要一些額外的分頁資訊 PageInfo
如
hasNextPage: Boolean!
hasPreviousPage: Boolean!
totalPageCount: Int
(通常非必要)講完分頁資訊,我們會用 Edge
的概念來表達實際的資料,一個 Edge
會由
指標 cursor
與上面提到的方式雷同。
不過因為 **cursor
本身設計並非 Huamn-Readable **,因此通常會做 base64 轉換,這樣對資料隱匿性也比較好,也比較不容易讓前端誤會資料的用途。
節點 node
為真正的實際資料 (終於!)
以上 PageInfo
與 Edge
兩著結合後就會成為如下圖的結構 (非 Schema ):
PostConnection {
edges [{
cursor,
node
}, ...],
pageInfo {
hasNextPage,
hasPreviousPage,
totalPageCount
}
}
以 Schema 來說會如下:
type PostConnection {
"資料"
edges: [PostEdge!]!
"分頁資訊"
pageInfo: PageInfo!
}
type PostEdge {
"指標。通常為一串 base64 字元"
cursor: String!
"實際 Post 資料"
node: Post!
}
type PageInfo {
"是否有下一頁"
hasNextPage: Boolean!
"是否有上一頁"
hasPreviousPage: Boolean!
"總頁數"
totalPageCount: Int
}
講完了 Type 形式,在參數上, Connection 模式在參數上也有一些特殊規定,因此在 Query.posts
就需要實作以下參數
first: Int
回傳開頭的前 N 筆資料after: String
會回傳該 curosr 後面的資料。一定要搭配 first
(first: 50)
、第二頁 (first: 50, after: cursor_50)
last: Int
回傳倒數的 N 筆資料。一定要搭配 before
before: String
會回傳該 curosr 前面的資料。一定要搭配 last
(last: 50, before: cursor_51)
所以整個 Schema 會如下:
type Query {
posts(
"回傳開頭的前 N 筆資料"
first: Int
"會回傳該 curosr 後面的資料。一定要搭配 `first`"
after: String
"回傳倒數的 N 筆資料。一定要搭配 `before`"
last: Int
"會回傳該 curosr 前面的資料。一定要搭配 `last`"
before: String
): PostConnection!
}
type PostConnection {
"資料"
edges: [PostEdge!]!
"分頁資訊"
pageInfo: PageInfo!
}
type PostEdge {
"通常為一串 base64 字元"
cursor: String!
"實際 Post 資料"
node: Post!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
totalPageCount: Int
}
接著再來看 Resolver 實作 (這邊屬於自由發揮部分,可以跳過):
const { UserInputError } = require('apollo-server');
const resolvers = {
Query: {
posts: async (root, { first, after, last, before, reverse }, { db }) => {
if (!first && after) throw new UserInputError('after must be with first')
if ((last && !before) || (!last && before)) throw new UserInputError('last and before must be used together')
if (first && after && last && before) throw new UserInputError('Incorrect Arguments Usage.')
let posts;
// 取得下一頁資料
if (first) {
posts = after
? await db.query(
'SELECT *, count(*) OVER() AS count FROM post WHERE created_at < $1 ORDER BY created_at DESC LIMIT $2',
[new Buffer(after, 'base64').toString(), first]
)
: await db.query('SELECT * FROM post ORDER BY created_at DESC LIMIT $1', [first]);
}
// 或是取得上一頁資料
if (last) {
posts = await db.query(
`SELECT * FROM (
SELECT *, count(*) OVER() AS count FROM post WHERE created_at > $1 ORDER BY created_at ASC LIMIT $2
) posts ORDER BY created_at DESC`,
[new Buffer(before, 'base64').toString(), last]
)
}
// 取得有條件 (WHERE) 但未限制數量 (LIMIT) 的真正數量
const countWithoutLimit = posts[0].count;
// 取得總數量
const allCount = db.query('SELECT count(*) as number FROM post;').count;
return {
edges: posts.map(post => ({
// 指標 (將 createdAt 做 base64)
cursor: Buffer.from(post.createdAt).toString('base64')
// 實際資料
node: post,
}))
pageInfo: {
// 檢查有無下一頁
hasNextPage: first ? countWithoutLimit > first : allCount > countWithoutLimit,
// 檢查有無上一頁
hasPreviousPage: last ? countWithoutLimit > last : alCount > countWithoutLimit,
// 總頁數
totalPageCount: Math.ceil(allCount / (fist || last))
}
}
},
}
以上就是 GraphQL 的 Connection 模式,當然可以以此基礎再新增更多資料如 GitHub 還有 totalCount
、totalDiskUsage
, startCursor
, endCursor
等等。
使用 Pagination 時一定會有一些效能上的損失,但說實在以現在的機器水準,通常不會差太多,如果真的效能掉太多那該優化的應該是你的 sql 語法或是 table 的設計。
有興趣的可以去 Shopify StoreFront GraphQL API 上面玩玩,當初我就是在這邊邊摸邊學會這套模式的 !
進去後可以使用 shop.collection (商品) 來試試!首先先拿第一頁的前三筆:
接著把第三筆的 cursor (eyJsYXN0X2lkIjoyNTc2OTc3MzEsImxhc3RfdmFsdWUiOiIyNTc2OTc3MzEifQ==
) 複製後加進搜尋列的 after
去拿第二頁。
再試試將搜尋列換成 before
與 last
看將第一筆的 cursor (eyJsYXN0X2lkIjozODkyNDIxNzksImxhc3RfdmFsdWUiOiIzODkyNDIxNzkifQ==
) 加入 before
來取得上一頁。
登愣!又得到跟第一頁一模一樣的資料囉!
經驗談: 資料設計若牽涉到時間的話,盡量以「毫秒」為基準,以秒為基準的欄位很容易出現重複的情況,而相值的排序結果就是交給上天決定了。另外如果使用 Relational DB 的朋友也記得幫 cursor 所用的欄位打上 index 加速排序與搜尋。
如果想看更多細節可以參考 Relay Cursor Connections Specification,裡面有詳盡的介紹。
Reference